fix: allow skill owner to move skill to an org acct while publishing existing skill#1562
fix: allow skill owner to move skill to an org acct while publishing existing skill#1562pushmatrix wants to merge 1 commit intoopenclaw:mainfrom
Conversation
|
@pushmatrix is attempting to deploy a commit to the 0xBuns Team on Vercel. A member of the Team first needs to authorize it. |
Greptile SummaryThe production change in All three new tests, however, share a mock gap that causes them to fail in CI before the new code path is reached. Confidence Score: 4/5Production logic is correct, but all three new tests fail before exercising the new code path — the feature is effectively untested. Two P1 findings: the positive tests never call convex/skills.rateLimit.test.ts — all three new tests need Prompt To Fix All With AIThis is a comment left during a code review.
Path: convex/skills.rateLimit.test.ts
Line: 1364-1376
Comment:
**`db.get` mock missing `"publishers:org"` — positive test never exercises new code**
With `ownerPublisherId: "publishers:org"` and `personalPublisher._id = "publishers:personal"`, the handler calls `requirePublisherRole(ctx, { publisherId: "publishers:org", ... })` at `skills.ts:5788`. That function calls `ctx.db.get("publishers:org")`, which returns `null` here, so `isPublisherActive(null)` → false → throws `ConvexError("Publisher not found")`. `patch` is never called and the `expect(patch).toHaveBeenCalledWith(...)` assertion fails.
Add the missing publisher to the `get` mock:
```suggestion
get: vi.fn(async (id: string) => {
if (id === "users:owner") {
return {
_id: "users:owner",
handle: "pushmatrix",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
if (id === "publishers:personal") {
return { _id: "publishers:personal", kind: "user", linkedUserId: "users:owner" };
}
if (id === "publishers:org") {
return { _id: "publishers:org", kind: "org", deletedAt: undefined, deactivatedAt: undefined };
}
return null;
}),
```
The same gap exists in the third test ("updates slug aliases", line 1501–1508) — add the identical `"publishers:org"` branch there as well.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: convex/skills.rateLimit.test.ts
Line: 1440-1495
Comment:
**Wrong error asserted — test fails for the wrong reason**
The test expects `rejects.toThrow(/slug is already taken/i)`, but execution never reaches the slug-conflict check. Because `ownerPublisherId: "publishers:strangerOrg"` ≠ `personalPublisher._id` (`"publishers:personal"`), `requirePublisherRole` is called. `ctx.db.get("publishers:strangerOrg")` returns `null` (not in the mock), so the function throws `ConvexError("Publisher not found")` — not "slug is already taken".
For this test to validate that a non-owner is blocked at the slug level, the mock needs `"publishers:strangerOrg"` to resolve as a valid publisher, and the stranger must appear as a member. Without those, the test only confirms that publishing to a non-existent publisher is blocked, which is already covered elsewhere.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix: allow skill owner to move skill to ..." | Re-trigger Greptile |
| get: vi.fn(async (id: string) => { | ||
| if (id === "users:owner") { | ||
| return { | ||
| _id: "users:owner", | ||
| handle: "pushmatrix", | ||
| deletedAt: undefined, | ||
| deactivatedAt: undefined, | ||
| }; | ||
| } | ||
| if (id === "publishers:personal") { | ||
| return { _id: "publishers:personal", kind: "user", linkedUserId: "users:owner" }; | ||
| } | ||
| return null; |
There was a problem hiding this comment.
db.get mock missing "publishers:org" — positive test never exercises new code
With ownerPublisherId: "publishers:org" and personalPublisher._id = "publishers:personal", the handler calls requirePublisherRole(ctx, { publisherId: "publishers:org", ... }) at skills.ts:5788. That function calls ctx.db.get("publishers:org"), which returns null here, so isPublisherActive(null) → false → throws ConvexError("Publisher not found"). patch is never called and the expect(patch).toHaveBeenCalledWith(...) assertion fails.
Add the missing publisher to the get mock:
| get: vi.fn(async (id: string) => { | |
| if (id === "users:owner") { | |
| return { | |
| _id: "users:owner", | |
| handle: "pushmatrix", | |
| deletedAt: undefined, | |
| deactivatedAt: undefined, | |
| }; | |
| } | |
| if (id === "publishers:personal") { | |
| return { _id: "publishers:personal", kind: "user", linkedUserId: "users:owner" }; | |
| } | |
| return null; | |
| get: vi.fn(async (id: string) => { | |
| if (id === "users:owner") { | |
| return { | |
| _id: "users:owner", | |
| handle: "pushmatrix", | |
| deletedAt: undefined, | |
| deactivatedAt: undefined, | |
| }; | |
| } | |
| if (id === "publishers:personal") { | |
| return { _id: "publishers:personal", kind: "user", linkedUserId: "users:owner" }; | |
| } | |
| if (id === "publishers:org") { | |
| return { _id: "publishers:org", kind: "org", deletedAt: undefined, deactivatedAt: undefined }; | |
| } | |
| return null; | |
| }), |
The same gap exists in the third test ("updates slug aliases", line 1501–1508) — add the identical "publishers:org" branch there as well.
Prompt To Fix With AI
This is a comment left during a code review.
Path: convex/skills.rateLimit.test.ts
Line: 1364-1376
Comment:
**`db.get` mock missing `"publishers:org"` — positive test never exercises new code**
With `ownerPublisherId: "publishers:org"` and `personalPublisher._id = "publishers:personal"`, the handler calls `requirePublisherRole(ctx, { publisherId: "publishers:org", ... })` at `skills.ts:5788`. That function calls `ctx.db.get("publishers:org")`, which returns `null` here, so `isPublisherActive(null)` → false → throws `ConvexError("Publisher not found")`. `patch` is never called and the `expect(patch).toHaveBeenCalledWith(...)` assertion fails.
Add the missing publisher to the `get` mock:
```suggestion
get: vi.fn(async (id: string) => {
if (id === "users:owner") {
return {
_id: "users:owner",
handle: "pushmatrix",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
if (id === "publishers:personal") {
return { _id: "publishers:personal", kind: "user", linkedUserId: "users:owner" };
}
if (id === "publishers:org") {
return { _id: "publishers:org", kind: "org", deletedAt: undefined, deactivatedAt: undefined };
}
return null;
}),
```
The same gap exists in the third test ("updates slug aliases", line 1501–1508) — add the identical `"publishers:org"` branch there as well.
How can I resolve this? If you propose a fix, please make it concise.
convex/skills.rateLimit.test.ts
Outdated
| ); | ||
| }); | ||
|
|
||
| it("blocks non-owner from claiming a skill with a different publisher", async () => { | ||
| const db = { | ||
| get: vi.fn(async (id: string) => { | ||
| if (id === "users:stranger") { | ||
| return { _id: "users:stranger", handle: "stranger", deletedAt: undefined, deactivatedAt: undefined }; | ||
| } | ||
| if (id === "users:owner") { | ||
| return { _id: "users:owner", handle: "alice", deletedAt: undefined, deactivatedAt: undefined }; | ||
| } | ||
| if (id === "publishers:personal") { | ||
| return { _id: "publishers:personal", kind: "user", linkedUserId: "users:stranger" }; | ||
| } | ||
| return null; | ||
| }), | ||
| query: vi.fn((table: string) => { | ||
| if (table === "skills") { | ||
| return { | ||
| withIndex: (name: string) => { | ||
| if (name !== "by_slug") throw new Error(`unexpected skills index ${name}`); | ||
| return { | ||
| unique: async () => ({ | ||
| _id: "skills:1", | ||
| slug: "shop", | ||
| ownerUserId: "users:owner", | ||
| ownerPublisherId: "publishers:other", | ||
| softDeletedAt: undefined, | ||
| moderationStatus: "active", | ||
| moderationFlags: undefined, | ||
| }), | ||
| }; | ||
| }, | ||
| }; | ||
| } | ||
| if (table === "publishers") { | ||
| return { | ||
| withIndex: () => ({ unique: async () => ({ _id: "publishers:personal", kind: "user", linkedUserId: "users:stranger" }) }), | ||
| }; | ||
| } | ||
| throw new Error(`unexpected table ${table}`); | ||
| }), | ||
| normalizeId: vi.fn(), | ||
| }; | ||
|
|
||
| await expect( | ||
| insertVersionHandler( | ||
| { db } as never, | ||
| createPublishArgs({ | ||
| userId: "users:stranger", | ||
| slug: "shop", | ||
| ownerPublisherId: "publishers:strangerOrg", | ||
| }) as never, | ||
| ), | ||
| ).rejects.toThrow(/slug is already taken/i); |
There was a problem hiding this comment.
Wrong error asserted — test fails for the wrong reason
The test expects rejects.toThrow(/slug is already taken/i), but execution never reaches the slug-conflict check. Because ownerPublisherId: "publishers:strangerOrg" ≠ personalPublisher._id ("publishers:personal"), requirePublisherRole is called. ctx.db.get("publishers:strangerOrg") returns null (not in the mock), so the function throws ConvexError("Publisher not found") — not "slug is already taken".
For this test to validate that a non-owner is blocked at the slug level, the mock needs "publishers:strangerOrg" to resolve as a valid publisher, and the stranger must appear as a member. Without those, the test only confirms that publishing to a non-existent publisher is blocked, which is already covered elsewhere.
Prompt To Fix With AI
This is a comment left during a code review.
Path: convex/skills.rateLimit.test.ts
Line: 1440-1495
Comment:
**Wrong error asserted — test fails for the wrong reason**
The test expects `rejects.toThrow(/slug is already taken/i)`, but execution never reaches the slug-conflict check. Because `ownerPublisherId: "publishers:strangerOrg"` ≠ `personalPublisher._id` (`"publishers:personal"`), `requirePublisherRole` is called. `ctx.db.get("publishers:strangerOrg")` returns `null` (not in the mock), so the function throws `ConvexError("Publisher not found")` — not "slug is already taken".
For this test to validate that a non-owner is blocked at the slug level, the mock needs `"publishers:strangerOrg"` to resolve as a valid publisher, and the stranger must appear as a member. Without those, the test only confirms that publishing to a non-existent publisher is blocked, which is already covered elsewhere.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 895f811bcc
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
convex/skills.rateLimit.test.ts
Outdated
| if (id === "publishers:personal") { | ||
| return { _id: "publishers:personal", kind: "user", linkedUserId: "users:owner" }; | ||
| } | ||
| return null; |
There was a problem hiding this comment.
Mock target publisher before asserting move-publisher behavior
These new tests pass ownerPublisherId values like publishers:org, but the db.get mock only returns publishers:personal and otherwise returns null. In insertVersion, that causes requirePublisherRole to fail with Publisher not found before the owner/non-owner slug logic runs, so the tests do not actually exercise the behavior they claim to validate and are likely to fail for the wrong reason.
Useful? React with 👍 / 👎.
|
This is useful for me! Thank you! |
|
Please note that CI / build (pull_request)Failing after 1m. All test cases must be successful before merge. @pushmatrix |
c407bda to
6380ec0
Compare
|
@momothemage good catch! Should be all fixed now |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6380ec087a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
convex/skills.ts
Outdated
| }); | ||
| throw new ConvexError(buildSlugTakenErrorMessage(skill, owner)); | ||
| } | ||
| await ctx.db.patch(skill._id, { ownerPublisherId, updatedAt: now }); |
There was a problem hiding this comment.
Preserve owner publisher when target publisher is omitted
This branch now always patches ownerPublisherId when the caller owns the skill and the resolved publisher differs, but ownerPublisherId is resolved to the caller’s personal publisher when omitted (insertVersion sets args.ownerPublisherId ?? personalPublisher._id). Legacy publish paths such as convex/httpApi.ts (publishVersionForUser(ctx, userId, args)) and convex/httpApiV1/skillsV1.ts also call publish without an owner publisher, so publishing an org-owned skill through those endpoints will silently transfer ownership (and alias ownership) from the org to personal. This is a behavior regression from “reject with slug taken” to “implicit ownership move” without explicit intent.
Useful? React with 👍 / 👎.
6380ec0 to
6d1a292
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6d1a2922f0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| } else if (skill.ownerUserId === userId && !explicitPublisherRequested) { | ||
| // Owner publishing via a legacy path that doesn't pass ownerPublisherId. | ||
| // Keep the existing publisher instead of moving or rejecting. | ||
| ownerPublisherId = skill.ownerPublisherId; |
There was a problem hiding this comment.
Re-validate org access before preserving ownerPublisherId
In the legacy-path branch (skill.ownerUserId === userId with no explicit ownerPublisherId), this code rewrites ownerPublisherId to skill.ownerPublisherId after the only authorization check has already run against the caller’s personal publisher. That means legacy publish endpoints that call publishVersionForUser without an ownerPublisherId (e.g. convex/httpApi.ts and convex/httpApiV1/skillsV1.ts) now allow publishing under an org-owned skill even when the caller no longer has org membership (or the org publisher is inactive), because no requirePublisherRole check is performed for the preserved org publisher.
Useful? React with 👍 / 👎.
6d1a292 to
30441e3
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 30441e3d04
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const explicitPublisherRequested = Boolean(args.ownerPublisherId); | ||
| if (skill.ownerUserId === userId && explicitPublisherRequested) { | ||
| await ctx.db.patch(skill._id, { ownerPublisherId, updatedAt: now }); |
There was a problem hiding this comment.
Require explicit move signal before changing owner publisher
This branch assumes args.ownerPublisherId means the caller explicitly asked to transfer ownership, but the normal web publish path always supplies an ownerPublisherId (via publishVersion target resolution) and the publish form defaults ownerHandle to the personal publisher for updates. In that flow, republishing an org-owned skill can now hit this branch and patch ownerPublisherId to personal even when the user only intended to publish a new version, creating an unintended ownership transfer regression from the previous slug-taken guard.
Useful? React with 👍 / 👎.
When a skill already has an ownerPublisherId, the insertVersion mutation treated any publisher mismatch as a slug conflict — even when the caller is the skill's ownerUserId. This made it impossible to move your own skill from a personal publisher to an org publisher (or vice versa). Now, when ownerPublisherId differs but the caller is the ownerUserId and explicitly provided a target publisher, we update the skill's publisher instead of throwing. Slug aliases are also updated to stay in sync, matching the pattern used by the transfer flow. When the caller is the owner but did NOT explicitly pass ownerPublisherId (legacy CLI paths), we preserve the existing publisher and re-validate org membership to avoid silently moving org skills back to personal or allowing publish without current org access. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
30441e3 to
dc9b18c
Compare
|
@momothemage oops, I see the types were failing. That's fixed now. Might running CI again? |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dc9b18c233
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const explicitPublisherRequested = Boolean(args.ownerPublisherId); | ||
| if (skill.ownerUserId === userId && explicitPublisherRequested) { | ||
| await ctx.db.patch(skill._id, { ownerPublisherId, updatedAt: now }); |
There was a problem hiding this comment.
Require explicit transfer signal before patching ownerPublisherId
Using Boolean(args.ownerPublisherId) as the transfer signal is unsafe because this field is auto-populated in normal publish flows rather than being a true user-intent flag. publishVersion always passes target.publisherId, and the web update flow auto-fills owner handle to the personal publisher (src/routes/publish-skill.tsx:204-210, reached from src/routes/dashboard.tsx:328), so republishing an org-owned skill can hit this branch and silently move ownership to personal via ctx.db.patch(skill._id, ...). Fresh evidence is that the new branch now performs ownership moves on that defaulted path, making this an introduced behavior regression rather than a theoretical caller misuse.
Useful? React with 👍 / 👎.
Fixes: #1431
Summary
insertVersionincorrectly threw "Slug is already taken" even when the caller owns the skillconvex/skills.ts:5820treated anyownerPublisherIdmismatch as a conflict with no escape hatch for the actual ownerownerUserId, the skill's publisher is updated instead of throwingTest plan
🤖 Generated with Claude Code